package uk.co.q3c.deplan.server.service;

import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.List;
import java.util.PriorityQueue;
import java.util.Queue;
import java.util.logging.Level;
import java.util.logging.Logger;

import uk.co.q3c.deplan.client.domain.resource.BaseCalendar;
import uk.co.q3c.deplan.client.domain.resource.PersistenceReferenceException;
import uk.co.q3c.deplan.client.domain.resource.Resource;
import uk.co.q3c.deplan.client.domain.resource.ResourceAssignmentException;
import uk.co.q3c.deplan.client.domain.resource.ResourceGroup;
import uk.co.q3c.deplan.client.domain.resource.ResourcePool;
import uk.co.q3c.deplan.client.domain.task.BaseTask;
import uk.co.q3c.deplan.client.domain.task.StartFinishTimes;
import uk.co.q3c.deplan.client.domain.task.Task;
import uk.co.q3c.deplan.client.domain.task.TaskPriorityComparator;
import uk.co.q3c.deplan.client.service.ServiceFactory;
import uk.co.q3c.deplan.client.util.Q3DateUtil;
import uk.co.q3c.deplan.server.domain.resource.ResourceBooking;
import uk.co.q3c.deplan.server.domain.util.Q3CalendarUtil;
import uk.co.q3c.deplan.server.rcp.model.TaskManager;
import uk.co.q3c.deplan.server.util.ProgressListener;
import uk.co.q3c.deplan.server.util.Q3Calendar;

/**
 * Levels resources. Set the start date (@link {@link #levelDate}) if you want it to be anything other than today, add the task or tasks to
 * be levelled using {@link #addCandidate(ProjectAbstractTask)}, then call {@link #level}. Don't forget to inject the {@link ResourcePool}
 * 
 * @inject progressListener;
 * @inject resourcePool<br>
 * @inject levelDate<br>
 * @author DSowerby 23 Jul 2008 20:06:39
 * 
 */
public class Leveller {
	transient protected final Logger logger = Logger.getLogger(getClass().getName());
	private final List<Task> candidates = new ArrayList<Task>();
	private final List<Task> summaries = new ArrayList<Task>();
	private final List<Task> completed = new ArrayList<Task>();
	private final List<BaseTask> holding = new ArrayList<BaseTask>();
	private final TaskPriorityComparator c = new TaskPriorityComparator();
	private final Queue<BaseTask> activeQueue = new PriorityQueue<BaseTask>(20, c);
	private final List<Task> allocated = new ArrayList<Task>();
	private ResourcePool resourcePool;
	private ProgressListener progressListener;

	public ProgressListener getProgressListener() {
		return progressListener;
	}

	public void setProgressListener(ProgressListener progressListener) {
		this.progressListener = progressListener;
	}

	/**
	 * The date to start levelling at (in other words, the first date on which to plan work). If this isn't set the {@link #level()} uses
	 * 'now';
	 */
	private Calendar levelDate;

	private BaseCalendar baseCalendar;
	private final SimpleDateFormat sdf = new SimpleDateFormat("dd MMM yyyy HH:mm:ss");
	private final ServiceFactory connection;

	public Leveller(ServiceFactory connection) {
		super();
		this.connection = connection;
		if (levelDate == null) {
			levelDate = GregorianCalendar.getInstance();
		}
	}

	/**
	 * Resets the leveller and clears any tasks in the candidate list
	 */
	public void reset() {
		candidates.clear();
		summaries.clear();
		completed.clear();
		holding.clear();
		activeQueue.clear();
		allocated.clear();
	}

	/**
	 * Adds the supplied task as a candidate for levelling. Summary tasks are expanded so that all sub tasks at all depths are included in
	 * the levelling, but only atomic tasks are actually added to the candidate list. If task is null call is ignored
	 * 
	 * @param task
	 * @throws PersistenceReferenceException
	 */
	public void addCandidate(BaseTask task) throws PersistenceReferenceException {
		// if task is atomic add to candidate list
		if (connection.taskService()
				.taskIsAtomic(task)) {
			candidates.add(task);
			// else recurse into this method, but do not add task as candidate (it is a summary task)
		} else {
			for (Task subTask : task.getSubTasks()) {
				addCandidate((BaseTask) subTask);
			}
		}
	}

	/**
	 * Levels tasks to available resource. If <code>levelDate</code> isn't set, use 'now' as the level date. Suspends
	 * {@link TaskManager#setAutoSave()} to avoid flooding the database with multiple property changes
	 * 
	 * @throws PersistenceReferenceException
	 * 
	 * @TODO currently this doesn't allow tasks to be interrupted once started. However it has been coded with a view to adding this
	 *       facility later
	 */
	public void level() throws PersistenceReferenceException {
		fireProgressSection();
		fireProgressSetSectionLimit(candidates.size());
		if (levelDate == null) {
			levelDate = GregorianCalendar.getInstance();
		}

		// put candidates in appropriate list
		processCandidates();
		if (resourcePool == null) {
			logger.severe("Resource Pool not set");
			return;
		}

		// clears profiles and sets dates span
		// resourcePool.clearProfiles(Model.getDbc(), levelDate.getTime(), Model.getPlanManager().getHorizon());

		// set up progress bar
		fireProgressSection();
		fireProgressSetSectionLimit(activeQueue.size());

		// activeQueue in priority order
		while (activeQueue.size() > 0) {

			BaseTask task = activeQueue.poll();
			if (!task.predecessorsAllocated()) {
				holding.add(task);
			} else {
				try {
					// task.getAssignedResource().setProfileStartDate(levelDate);
					bookFullTask(task);
					allocated.add(task);
					fireProgressStep();
					activeQueue.addAll(holding);
					holding.clear();
				} catch (ResourceAssignmentException e) {
					// use "unnamed resource"????????????????????????
				}
			}
		}

	}

	private void fireProgressSetSectionLimit(int taskCount) {
		if (progressListener != null) {
			progressListener.setSectionLimit(taskCount);
		}
	}

	private void fireProgressSection() {
		if (progressListener != null) {
			progressListener.incSection();
		}
	}

	private void fireProgressStep() {
		if (progressListener != null) {
			progressListener.incStep();
		}
	}

	public void processCandidates() throws PersistenceReferenceException {
		for (Task task : candidates) {
			assessCandidate(task);
			fireProgressStep();
		}
		candidates.clear();

	}

	/**
	 * Assesses how each candidate should be processed, and excludes those which cannot be levelled (for example summary tasks), or those
	 * which would cause a failure (for example a task with work but no resource)
	 * 
	 * @param task
	 * @throws PersistenceReferenceException
	 */
	private void assessCandidate(Task task) throws PersistenceReferenceException {
		if (!connection.taskService()
				.getHierarchy()
				.isAtomic(task.getKey())) {
			summaries.add(task);
			return;
		}
		if (task.isComplete()) {
			completed.add(task);
			return;
		}

		// not a summary and not complete, clear previous levelling
		// not a summary, safe to cast
		Task at = task;
		at.clearLevelling();

		// those whose predecessors not complete or not allocated move to
		// holding

		if (task instanceof BaseTask) {
			BaseTask rt = (BaseTask) task;
			Resource resource = rt.getAssignedResource();
			if (resource == null) {
				logger.info(task.qualifiedName() + " has no resource assigned, excluded from levelling process");
				return;
			} else {
				if (resource instanceof ResourceGroup) {
					ResourceGroup group = (ResourceGroup) resource;
					if (!group.hasResources()) {
						logger.info(task.qualifiedName()
								+ " has resource group assigned, but no resources in the group.  Excluded from levelling process");
						return;
					}
				}
			}
			if (rt.getAssignedUnits() <= 0) {
				logger.info(task.qualifiedName() + " has no resource units assigned, excluded from levelling process");
				return;
			}

			if (rt.isFullyAllocated()) {
				allocated.add(task);
			} else {
				activeQueue.add(rt);
			}
			return;
		}
		logger.severe("task not assessed: " + task.qualifiedName());
	}

	public Date getLevelDate() {
		return levelDate.getTime();
	}

	/**
	 * Sets the levelling start date to <code>levelDate</code>, adjusted to the start of the day as defined by
	 * {@link Q3DateUtil#startOfDay_calendar(Date)}
	 * 
	 * @param levelDate
	 */
	public void setLevelDate(Date levelDate) {
		this.levelDate = Q3CalendarUtil.startOfDay_calendar(levelDate);
	}

	public BaseCalendar getBaseCalendar() {
		if (baseCalendar == null) {
			baseCalendar = connection.calendarService()
					.findBaseCalendar();
		}
		return baseCalendar;
	}

	/**
	 * Books a task with fixed duration, starting at <code>di</code> http://deplan.wiki.sourceforge.net/Levelling#fixedDuration
	 * 
	 * @param task
	 * @param di
	 * @throws PersistenceReferenceException
	 */
	void bookFixedDurationTask(BaseTask task, Calendar di) throws PersistenceReferenceException {
		double requiredDays = task.getDuration();
		// duration not set
		if (requiredDays <= 0) {
			task.setStartRemaining(di.getTime());
			task.setPlannedFinish(di.getTime());
			// task.setWorkRemaining(0);
			return;
		}

		double units = task.getAssignedUnits();
		Resource resource = task.getAssignedResource();

		boolean allocationComplete = false;
		double allocatedDays = 0;

		Q3Calendar startDi = new Q3Calendar(di.getTimeInMillis());
		Q3Calendar countDi = new Q3Calendar(di.getTimeInMillis());

		List<ResourceBooking> provisionalBookings = new ArrayList<ResourceBooking>();
		logger.info("attempting to schedule fixed duration task '" + task.qualifiedName() + "'" + sdf.format(countDi.getTime()) + " using resource "
				+ resource.getName());

		while (!allocationComplete) {

			int effortWanted = (int) ((getBaseCalendar().getWorkingTime(countDi.getTime()) * units));

			// if this is < 1 we are dealing with the non-integer rump of the
			// duration
			if (requiredDays - allocatedDays < 1) {
				effortWanted = (int) Math.round(effortWanted * (requiredDays - allocatedDays));
			}
			int effortAvail = resource.effortAvailable(countDi.getTime(), units);

			if (effortAvail >= effortWanted) {
				// only make a booking if there is effort available to book - if
				// effortAvail == 0
				// then it's a weekend or bank holiday or similar
				if ((effortAvail > 0) && (effortWanted > 0)) {
					ResourceBooking booking = new ResourceBooking();
					booking.setDi(countDi);
					booking.setEffort(effortWanted);
					logger.info(booking.toString());
					allocatedDays = allocatedDays + 1;
					provisionalBookings.add(booking);
				}
				countDi.incDay();

			} else {
				// if base calendar also wouldn't have capacity then ignore this day,
				// but if it does then this allocation has failed and the start date
				// should be moved to one ahead of the current value of countDi and
				// the process started again.
				logger.info("clearing provisional bookings");
				provisionalBookings.clear();
				startDi.incDay();
				countDi.setTime(startDi.getTime());
				allocatedDays = 0;
				logger.info("start day moved to " + sdf.format(countDi.getTime()));
			}
			allocationComplete = (allocatedDays >= requiredDays);
		}
		// confirm bookings
		StartFinishTimes sft = null;
		for (ResourceBooking booking : provisionalBookings) {
			if (booking.getEffort() > 0) {
				sft = resource.bookEffort(booking.getEffort(), booking.getDi()
						.getTime(), task);
				task.allocateWork(booking.getEffort(), sft);
			}
		}
		if (task.isFullyAllocated()) {
			task.setPlannedFinish(new Date(sft.finish));
		}
		// for fixed duration amount of work is determined by what's allocated
		// for
		// the duration
		// this was changed in move to total work
		// task.setWorkRemaining(task.getWorkAllocated());
	}

	public void bookFullTask(BaseTask task) throws ResourceAssignmentException, PersistenceReferenceException {
		if ((task.getAssignedResource() == null) || (task.getAssignedUnits() <= 0)) {
			String msg = "No resource specified to bookFullTask task: " + task.qualifiedName();
			logger.info(msg);
			throw new ResourceAssignmentException(msg);
		}
		// Resource resource = task.getAssignedResource();
		// if (resource.getProfileStartDate() == null) {
		// resource.setProfileStartDate(GregorianCalendar.getInstance());
		// }

		// if processing a task which has predecessors, make sure it can't start
		// until predecessors are completed
		// DateIndex di = new
		// DateIndex(resource.getProfileStartDate().getTime());
		Q3Calendar di = new Q3Calendar(levelDate.getTimeInMillis());
		if (task.hasPredecessors()) {
			if (task.lastPredecessorFinish() > di.getTime()
					.getTime()) {
				di.setTimeInMillis(task.lastPredecessorFinish());
			}
		}

		task.setStartRemaining(null);

		if (task.isFixedDuration()) {
			bookFixedDurationTask(task, di);

		} else {
			bookFixedWorkTask(task, di);// fixed work
		}
	}

	private void bookFixedWorkTask(BaseTask task, Q3Calendar di) throws PersistenceReferenceException {
		// Q3Calendar di = new Q3Calendar(cal.getTimeInMillis());
		// loop until task fully allocated
		Resource resource = task.getAssignedResource();
		StartFinishTimes sft = null;
		while (!task.isFullyAllocated()) {
			logger.info("starting allocation for resource: " + resource.getName() + " on " + sdf.format(di.getTime()));

			double units = task.getAssignedUnits();
			int effortAvail = resource.effortAvailable(di.getTime(), units);

			// only process if there is some effort available
			if (effortAvail > 0) {
				int effortWanted = task.unallocatedWork();
				int allocation = 0;
				if (effortWanted < effortAvail) {
					allocation = effortWanted;
				} else {
					allocation = effortAvail;
				}
				if (allocation > 0) {
					sft = resource.bookEffort(allocation, di.getTime(), task);
					if (logger.isLoggable(Level.FINE)) {
						Date st = new Date(sft.start);
						Date fi = new Date(sft.finish);
						String msg = task.getName() + " start = " + sdf.format(st) + " plannedFinish = " + sdf.format(fi);
						logger.fine(msg);
					}
					task.allocateWork(allocation, sft);
				}
			}
			di.incDay();
			// di.rollToNextDay();
		}// while fixed work

		if (task.isFullyAllocated()) {
			task.setPlannedFinish(new Date(sft.finish));
		}
	}

	public ResourcePool getResourcePool() {
		return resourcePool;
	}

	public void setResourcePool(ResourcePool resourcePool) {
		this.resourcePool = resourcePool;
	}

	public int candidateCount() {
		return candidates.size();
	}

	public int activeCount() {
		return activeQueue.size();
	}

}